Skip to content

fix: record nothing for a process with single-byte coverage counters - #41

Closed
esnunes wants to merge 1 commit into
mainfrom
fix/single-byte-counters
Closed

esnunes wants to merge 1 commit into
mainfrom
fix/single-byte-counters

Conversation

@esnunes

@esnunes esnunes commented Nov 10, 2026

Copy link
Copy Markdown
Member

What

The observer now spots images built with LLVM's single-byte coverage counters and records nothing for any process that loads one. Before, it wrote records whose deltas were wrong.

  • has_byte_counters() runs once per instrumented image in discover_images(). On a hit, discover_images() fails the way it does for any other image it can't handle: stop_recording() removes images.tsv and records.bin for the process, and the tests run as usual.
  • New fixture Fixtures/ByteCoverage (scripted, like Fixtures/StaticArchive): an Objective-C XCTest bundle plus the C code it tests, built three ways. A new parameterized test, recordsNothingForAnImageWithSingleByteCounters, checks each build.
  • README: new limitation bullet. AGENTS.md: the fixture, plus a rule that counters are read as 8-byte counts only.

No new environment variables, no record format change, and nothing changes for images with 8-byte counters.

Why / root cause

discover_images() and write_record() treat every image's __llvm_prf_cnts as an array of uint64_t. In single-byte mode (-mllvm -enable-single-byte-coverage), clang gives each block a 1-byte counter. The counter starts at 0xFF and is set to 0 once the block runs. Read as words, 8 counters share one uint64_t, and clearing any of them makes the word smaller, so current - snapshot wraps around. Here is what main's observer writes for the new fixture's byte build:

testClassify [(0, 0xffffff00ff00ff01), (1, 0xffffffff00000001)]
testSum      [(0, 0x00ff010000000000), (1, 0x0000010000000000), (2, 0xffffffffffff0001)]

Those indices aren't counters, and those values aren't counts. Tuist's reader would turn them into wrong per-test evidence: either evidence it can't map, or lines evaluated from garbage expression values.

How it's detected

  1. The image's profile version. __llvm_profile_raw_version holds the same version the profile runtime writes into the .profraw header. Its VARIANT_MASK_BYTE_COVERAGE bit (1 << 60) is how LLVM itself, and DataDog's swift-code-coverage, tell the modes apart. Every instrumented image defines it: clang emits it (in __TEXT,__const) in single-byte mode, and otherwise the profile runtime linked into the image defines it (in __DATA,__data). I checked both a clang binary and the Swift ExamplePackageTests bundle. The symbol is hidden, so it never reaches dyld's export trie. The observer walks the image's LC_SYMTAB in __LINKEDIT for it once per image, before the first scope. It calls no profile runtime function and writes nothing.
  2. The counters section size. A section that isn't a whole number of 8-byte counters can't hold 64-bit counters. This catches single-byte images stripped of local symbols (strip -x), which no longer have the version variable.

Of these, the version is the authoritative signal. The size check is a free backstop. It misses only a stripped single-byte image whose counter count happens to be a multiple of 8, and the README lists that case.

Why stop the whole process instead of leaving the image out

Leaving the image out of images.tsv would still produce evidence for the tests in that process, just without the image's code. A test that ran code in that image would then look like a test that didn't. Whether that's safe depends on how Tuist reconciles per-test evidence with the per-target evidence it reads from the processes' profiles, and the observer can't check that. A process with no output is a case every reader already handles: it's the existing failure guarantee. Single-byte mode needs an explicit -mllvm flag, so stopping costs little in practice.

Alternatives considered

  • Sum NumCounters from __llvm_prf_data and compare with the section size. This works without symbols, but the record layout changes with LLVM versions (Tuist's reader and FunctionTable.swift already try several). It would tie the observer to LLVM's layout for the first time, and a future layout it misreads could switch recording off for everyone.
  • Call __llvm_profile_get_version, as DataDog does. It's hidden per image as well, so finding it takes the same symbol-table walk. Reading the variable directly avoids calling into the runtime at all, which this package deliberately never does.
  • Refuse a word that went down between snapshot and record. This needs no layout and no symbols, and every single-byte change is a decrease. But a lost update between threads in ordinary 64-bit counters (non-atomic increments) can also make a counter go down. That would turn rare races into lost attribution, so I left it out.
  • Support single-byte counters (record 0xFF → 0 flips). Out of scope: the record layout, kRecordVersion and Tuist's reader would all need to change, and the reader's function table can't place byte counters today.

Impact

  • Images with 8-byte counters (Swift, and clang's default): output is unchanged. Discovery costs one linear scan of each instrumented image's symbol table, once per process.
  • Processes with a single-byte image: no images.tsv or records.bin, so Tuist sees no per-test evidence for them instead of wrong evidence. launches.tsv, unskippable.tsv and skipping are unaffected.

Validation

  • mise run test on Xcode 26.3 (.xcode-version): all 13 tests pass (19 cases, including the UITests fixture on an iOS simulator and the macOS and iOS xcodebuild runs). I ran the task's swift test directly with tuist 4.214.0 first on PATH: under mise run, mise shadows that tuist binary in this environment, and the 5 tests that need tuist can't start.
  • mise run lint: clean (SwiftFormat, SwiftLint, clang static analyzer).
  • The new test passes for all three builds: word records testClassify and testSum, each with only its own C function; byte and byte-stripped record nothing. The fixture checks which signal each build exercises: the byte build is padded to a whole number of 8-byte words, so only the profile version can catch it, and the byte-stripped build has no version symbol and a section size that isn't a multiple of 8.
  • Against main's observer, the byte build writes the wrapped deltas shown above.
  • Xcode 26.6 and 27.0 (Apple clang 21): recordsNothingForAnImageWithSingleByteCounters and needsObjCToLinkTheObserverFromAStaticArchive pass. That covers detection with a newer clang and no false positive on a Swift image. No Swift Testing path changed, so I didn't run the full suite on 26.6.

🤖 Generated with Claude Code

The observer reads every image's __llvm_prf_cnts as 8-byte counters. With
LLVM's single-byte coverage (-mllvm -enable-single-byte-coverage), counters
are one byte, start at 0xFF and are cleared once their code runs, so eight
of them shared a word and the recorded deltas wrapped into garbage that
Tuist's reader would turn into wrong per-test evidence.

discover_images() now checks each instrumented image's profile version,
__llvm_profile_raw_version, for VARIANT_MASK_BYTE_COVERAGE, looking the
hidden variable up in the image's symbol table, and, for images stripped
of it, whether the counters section is a whole number of 8-byte counters.
A hit stops recording for the process, as any other failure does, so a
reader finds no output instead of wrong records.

Fixtures/ByteCoverage builds an Objective-C bundle and the C code it tests
with 8-byte counters, single-byte counters, and single-byte counters
stripped of local symbols.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@esnunes
esnunes marked this pull request as ready for review November 10, 2026 03:17
@esnunes
esnunes requested a review from a team as a code owner November 10, 2026 03:17
@esnunes
esnunes requested review from fortmarek and removed request for a team November 10, 2026 03:17
@esnunes

esnunes commented Nov 10, 2026

Copy link
Copy Markdown
Member Author

Closing for now: single-byte counters need an explicit -mllvm -enable-single-byte-coverage on C/Objective-C code (swiftc ignores it), so nobody hits this today. The branch fix/single-byte-counters is kept; reopen if Xcode's default or a project's flags change.

@esnunes esnunes closed this Nov 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant